Skip to content

SDKS-2810: Add Invisible reCAPTCHA, hCaptcha, and Enterprise#478

Merged
SteinGabriel merged 1 commit into
mainfrom
SDKS-2810
May 27, 2026
Merged

SDKS-2810: Add Invisible reCAPTCHA, hCaptcha, and Enterprise#478
SteinGabriel merged 1 commit into
mainfrom
SDKS-2810

Conversation

@SteinGabriel

@SteinGabriel SteinGabriel commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

Summary

https://pingidentity.atlassian.net/browse/SDKS-2810

Adds invisible reCAPTCHA v2 and invisible hCaptcha support to the login widget via a new configuration({ captcha: { mode: 'invisible' } }) option. Also adds a dedicated ReCaptchaEnterpriseCallback handler for AM journeys using the Enterprise CAPTCHA node. The widget now auto-injects CAPTCHA scripts — no manual <script> tag required from consumers for any variant.


Changes

core/captcha.config.ts

  • Zod schema (captchaConfigSchema) for the captcha config option — validates mode: 'visible' | 'invisible'. Strict so unknown keys throw at parse time.

core/journey/_utilities/captcha.effects.ts

  • resolveGrecaptcha() — prefers window.grecaptcha.enterprise, falls back to window.grecaptcha. Transparent to consumers regardless of which script they loaded (classic api.js or enterprise.js).
  • loadCaptchaScript({ src, provider }) — injects a <script> tag and resolves when the provider API is ready. No-ops if the provider is already on window (pre-loaded or test stub). Race-safe: settled flag, error listener, TOCTOU double-check.
  • renderCaptcha(...) — visible v2 render for both Google and hCaptcha.
  • renderCaptchaInvisible(...) — invisible v2 render + execute for both providers.

core/journey/stages/_effects/captcha.effects.ts

  • Re-exports renderCaptcha, renderCaptchaInvisible, resolveGrecaptcha from _utilities/captcha.effects.ts. Preserves the layer boundary so stages/ consumers keep their existing import path.

core/journey/callbacks/recaptcha-enterprise/

  • recaptcha-enterprise.svelte — handles ReCaptchaEnterpriseCallback. Visible checkbox or score-based invisible flow driven by captchaMode. Calls setAction(), setResult(), and setClientError() on execute. Inline <Alert type="error"> on failure.
  • recaptcha-enterprise.effects.tsrenderEnterpriseCaptcha, executeEnterpriseCaptcha, loadEnterpriseScript. loadEnterpriseScript appends ?render=SITE_KEY for invisible/score-based keys (required for execute()).
  • recaptcha-enterprise.utilities.tsbuildEnterpriseScriptSrc pure helper.

core/journey/callbacks/recaptcha/recaptcha.svelte

  • Extended for invisible v2: awaits loadCaptchaScript, then calls renderCaptchaInvisible on mount for both Google and hCaptcha.
  • captchaMode driven by callbackMetadata.initOptions.mode (threaded via buildCallbackMetadata — no store).
  • CaptchaMode typed declaration prevents reactive widening to string.
  • Script auto-injection: onMount awaits loadCaptchaScript before render; v3 path also self-injects api.js.

core/journey/_utilities/metadata.utilities.ts

  • buildCallbackMetadata accepts a new initializationOptions param. For ReCaptchaCallback and ReCaptchaEnterpriseCallback, merges captcha and recaptchaAction from initializationOptions into initOptions on the returned metadata object. Both callback components read mode and action from callbackMetadata.initOptions — no store subscriptions.

core/journey/_utilities/callback-mapper.svelte

  • Registered CallbackType.ReCaptchaEnterpriseCallbackrecaptcha-enterprise.svelte.

core/journey/journey.interfaces.ts

  • CaptchaMode = 'visible' | 'invisible' exported type — shared across both callback components.

packages/login-widget/src/lib/interfaces.ts + api.utilities.ts

  • captcha?: { mode?: CaptchaMode } option on WidgetConfigOptions. Validated via captchaConfigSchema.parse() at both initialize and set call sites.

packages/login-widget/README.md

  • CAPTCHA Configuration section updated: accurate script auto-injection behavior, 'visible' | 'invisible' mode values, Enterprise backward-compatibility note.

Architecture notes

captchaMode threading (no store)

captcha.store.ts was removed during review. captchaMode flows through buildCallbackMetadata's initializationOptions param → CallbackMetadata.initOptions.mode → component prop. This keeps the value co-located with the callback that uses it, consistent with the WebAuthn autofill pattern.

Script injection

The widget now owns CAPTCHA script injection for all variants. loadCaptchaScript early-exits if the provider API is already on window, so pre-loading in the host page still works. Enterprise invisible keys require ?render=SITE_KEY in the script URL for execute()buildEnterpriseScriptSrc handles this automatically.

Layer boundary

Callbacks import shared effects from core/journey/_utilities/captcha.effects.ts. stages/ code re-exports from there via stages/_effects/captcha.effects.ts. Callbacks never import from stages/.


Tests

  • Unit (156 pass):
    • captcha.effects.test.ts — 7 tests (resolveGrecaptcha, loadCaptchaScript: new inject, reuse existing, grecaptcha.ready, onerror reject)
    • stages/_effects/captcha.effects.test.ts — 12 tests (renderCaptcha, renderCaptchaInvisible, Enterprise namespace, classic fallback)
    • recaptcha-enterprise.effects.test.ts — 9 tests (renderEnterpriseCaptcha, executeEnterpriseCaptcha, null-grc guard)
    • recaptcha-enterprise.utilities.test.ts — 2 tests (buildEnterpriseScriptSrc)
    • recaptcha.utilities.test.ts — 2 tests (checkForHCaptcha)
    • metadata.utilities.test.ts — 10 tests (6 new initializationOptions coverage)
  • Storybook stories: VisibleGoogle, VisibleHCaptcha, InvisibleGoogle, InvisibleError + VisibleEnterprise, InvisibleEnterprise, InvisibleEnterpriseError
  • Playwright E2E (6 tests — modal + inline suites):
    • Classic visible token submit
    • Enterprise visible render check
    • Enterprise invisible execute + action + token submit
    • All use route.fulfill AM mocking — no live AM dependency

How to test

1. Unit tests

pnpm --filter @forgerock/login-widget exec vitest run 'captcha'

All 156 tests should pass.

2. Storybook

pnpm storybook

Navigate to Callbacks/ReCaptcha and Callbacks/ReCaptchaEnterprise — verify all story variants render without errors.

3. E2E tests

pnpm build:app
pnpm --filter @forgerock/login-widget-e2e exec playwright install chromium
pnpm ci:e2e -- tests/widget/modal/widget-modal.recaptcha.test.js
pnpm ci:e2e -- tests/widget/inline/widget-inline.recaptcha.test.js

4. Manual AM testing

Setting up a CAPTCHA node in an AM journey

Classic reCAPTCHA v2 (visible or invisible):

  1. In AM admin console, open or create a journey.
  2. Add a CAPTCHA node (Authentication Nodes → CAPTCHA).
  3. Set CAPTCHA Site Key to your Google reCAPTCHA v2 site key (registered for your domain + localhost).
  4. Set CAPTCHA Secret Key to the corresponding secret key — AM uses this for server-side verification.
  5. For hCaptcha: set the site key and secret key from your hCaptcha dashboard. The node sets captchaDivClass: h-captcha automatically for hCaptcha keys.
  6. Wire the node between Start and your success/failure outcomes.

Invisible mode: No AM config change needed. Pass configuration({ captcha: { mode: 'invisible' } }) in the widget consumer.

reCAPTCHA Enterprise:

  1. Add a reCAPTCHA Enterprise node (Authentication Nodes → reCAPTCHA Enterprise).
  2. Set the Enterprise Site Key (from Google Cloud Console, reCAPTCHA Enterprise product — must be a v2 checkbox key for visible mode or a score-based key for invisible/score mode).
  3. The node sends ReCaptchaEnterpriseCallback — the widget handles this automatically via the new component.
  4. For score-based (invisible) keys: pass configuration({ captcha: { mode: 'invisible' } }).

Verifying the integration

Visible mode (v2 checkbox):

  • The CAPTCHA checkbox widget renders inside the login form.
  • Click the checkbox — it should show the green checkmark (or trigger an image challenge).
  • Click Sign In — the journey should advance. If AM server-side verification fails, the journey returns to the CAPTCHA step.

Invisible mode (v2 invisible / hCaptcha invisible):

  • No visible widget appears in the form.
  • On mount, the widget executes the CAPTCHA automatically in the background.
  • If a challenge is triggered by Google/hCaptcha (low-confidence score), a challenge popup appears.
  • Once resolved, Sign In proceeds automatically — the token is already set.
  • On failure or expiry, an inline error alert appears: "CAPTCHA verification failed. Please try again."

Enterprise invisible (score-based v3-style):

  • No visible widget.
  • execute() fires on mount with the configured recaptchaAction (e.g. 'LOGIN').
  • Token, action, and any client error are submitted to AM.
  • Verify in AM audit logs that the assessment score is present in the session.

Verifying token submission via browser DevTools:

  1. Open DevTools → Network tab.
  2. Trigger the CAPTCHA step.
  3. Look for the AM journey authenticate POST request.
  4. In the request body, find the callbacks array. The ReCaptchaCallback or ReCaptchaEnterpriseCallback input should have a non-empty value for IDToken<n>token.
  5. For Enterprise, also verify IDToken<n>action is set to your configured action string.

Verifying Enterprise backward compatibility:

  • Load enterprise.js — confirm resolveGrecaptcha() resolves to window.grecaptcha.enterprise.
  • Load api.js instead — confirm it falls back to window.grecaptcha without errors.
  • Both flows should complete the journey successfully.

@changeset-bot

changeset-bot Bot commented Apr 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3d52dfc

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@forgerock/login-widget Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@SteinGabriel SteinGabriel marked this pull request as draft April 30, 2026 22:31
@SteinGabriel SteinGabriel marked this pull request as ready for review April 30, 2026 22:42

@vatsalparikh vatsalparikh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that Gabriel has added callbacks and stages using the old legacy forgerock sdk, so this will stop working completely as soon as my journey client changes are merged to main. I suggest we use the new journey client here, because my journey client PR #468 will soon be merged to main.

@vatsalparikh vatsalparikh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Initial review comments

Comment thread apps/login-app/src/app.html Outdated
Comment thread apps/login-app/src/routes/(app)/+page.svelte Outdated
const recaptchaClass: string =
callback?.getOutputByName<string>('captchaDivClass', 'h-captcha') ?? 'h-captcha';

const captchaProvider: 'hcaptcha' | 'grecaptcha' =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are handling both hCaptcha and recaptcha in this file and in the folder in general, so we should rename all files under and along with recaptcha folder to be captcha instead

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point. I think we should add this to a new tech debt ticket, along with a few other comments from this PR, to avoid adding more changes into an already large diff.

Comment thread core/journey/stages/_utilities/recaptcha.utilities.ts Outdated
Comment thread core/journey/stages/_utilities/recaptcha.utilities.ts Outdated
Comment thread e2e/tests/widget/modal/widget-modal.recaptcha.test.js
Comment thread e2e/tests/widget/modal/widget-modal.recaptcha.test.js
Comment thread packages/login-widget/src/lib/_utilities/api.utilities.ts Outdated

@cerebrl cerebrl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd really like for us to reconsider some architectural choices here. If you want, we can jump on a call tomorrow and discuss it.

Comment thread core/journey/callbacks/recaptcha-enterprise/recaptcha-enterprise.svelte Outdated
Comment thread core/journey/callbacks/recaptcha-enterprise/recaptcha-enterprise.svelte Outdated
Comment thread core/journey/callbacks/recaptcha-enterprise/recaptcha-enterprise.svelte Outdated
}) {
if (nameOfCaptcha === 'hcaptcha' && window.hcaptcha) {
return window.hcaptcha.render('fr-recaptcha', {
return window.hcaptcha.render(elementId, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This deviates from our conventions of utility functions being pure, stateless functions. Typically, anything that involves DOM or Window use is in a Svelte component. But, if something needs to access external systems, we would call it an "effect" (not to be confused with Effect, the library).

With that said, I see that this file already has window use, so we can leave it. But, I do want to stress that we should move away from this as it should be considered an anti-pattern.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. We can tackle this alongside the stage import in recaptcha.svelte callback file in a new ticket.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since I was already doing some refactoring, I went ahead and replaced the global window assignments with closures passed directly to renderCaptcha / renderCaptchaInvisible / renderEnterpriseCaptcha. Both reCaptcha and hCaptcha support a programmatic render() API that accepts function refs, so no window lookup needed, no shared-global risk, no core/window.d.ts type.

@SteinGabriel SteinGabriel force-pushed the SDKS-2810 branch 3 times, most recently from 63bd15f to c8ec83e Compare May 5, 2026 21:33
Comment thread core/captcha.store.ts Outdated

@vatsalparikh vatsalparikh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, looking good now, the codebase looks good.

Justin already mentioned comments about using callback metadata (or similar) instead of captcha store and putting window in a separate file so those utility files should become leaner, and any effectful code should be under effects file for both enterprise and normal recaptcha.

I tested recaptcha enterprise with login journey. Gabriel helped configuring a new recatpcha enterprise, and I was able to solve the recaptcha but login failed. It's possible that the new config was missing something.

Comment thread core/journey/callbacks/recaptcha/recaptcha.mock.ts Outdated
Comment thread core/journey/callbacks/recaptcha-enterprise/recaptcha-enterprise.svelte Outdated
Comment thread packages/login-widget/package.json
@SteinGabriel SteinGabriel force-pushed the SDKS-2810 branch 2 times, most recently from a26e3f9 to daa76ed Compare May 8, 2026 00:12

@ryanbas21 ryanbas21 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First pass, this will take me a few reads because its ReCaptcha.

For CAPTCHA-enabled journeys: use enterprise.js, not api.js —
new Google keys require the Enterprise namespace.
-->
<script src="https://www.google.com/recaptcha/enterprise.js" async defer></script>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we need the siteKey in here? or is this changed now?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, for Enterprise the key is passed to render(element, { sitekey }) or execute(siteKey) at render time, the script just needs to be loaded. The siteKey value comes directly from callback?.getSiteKey().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: siteKey isn't needed in the API call for reCaptcha v2 only. Enterprise and v3 does need it though. Fixing now.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be fixed.

Comment thread core/journey/stages/_utilities/recaptcha.utilities.ts Outdated
PingOneProtectInitializeCallback,
PollingWaitCallback,
ReCaptchaCallback,
ReCaptchaEnterpriseCallback,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we're now creating a distinct ReCaptchaEnterpriseCallback.

Because now in the new "Recaptcha world" there is no significant difference between this for google, this feels like we're diverting our structure away from the actual core Domain (ReCaptcha)

I think maybe what we need to do is align more against HCaptcha vs Google ReCaptcha.

Since GoogleReCaptcha has Enterprise, and others, but they are all under the domain of Enterprise Recaptcha, it can be a bit more resilient to future changes from Google. If they rename, add something, we have just Google based ReCaptcha component that handles that logic, we operate there.

HCaptcha being distinct creates the same separation.

My Concern is that we are creating the separation "Enterprise" vs not, and then if HCaptcha has or adds an Enterprise, are we going to put all Enterprise based logic in the recaptcha-enterprise component?

It feels like the abstraction may be on the wrong thing and should be on the Google vs HCaptcha.

This may help clean up some of the mess I originally created when writing this because now the logic for each is coupled (correctly) to the component that it is.

The issue this does create is in this file, since we check the type, we'd need one more layer of logic which says "Which type" of Recaptcha is this.

I think in this case, this second "check" is worth doing for the separation we can create.

Thoughts?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point. The component split follows AM's callback structure, not our design. AM sends ReCaptchaEnterpriseCallback (different from ReCaptchaCallback) for the Enterprise captcha node, and it has a different API too. One component for both would need duck-typing or internal branching, which can be harder to read and test. hCaptcha only uses ReCaptchaCallback, no Enterprise variant, so it stays in the classic component. If AM adds an hCaptcha Enterprise callback later, it could get its own component or reuse the enterprise one.

If AM is our source of truth for callback components, which it mostly is (one exception aside), a dedicated ReCaptchaEnterpriseCallback makes more sense. That said, I get your point about the abstraction belonging at the ReCaptcha vs. hCaptcha level. It really comes down to following AM's structure or creating our own for captcha callbacks.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hear this, but i'm not sure that following AM nodes here is the right decision since the real implementation is more specific to the provider. I'm not hard set on this but just my thoughts

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see your point. The existing reCaptchaCallback naming can be specially misleading/confusing since it's used by both reCaptcha and hCaptcha, even though that's the callback name returned by the Captcha node. reCaptchaEnterprise is more consistent with the journey node and its intended use.

I'll spend some time splitting the callback components into hcaptcha.svelte and recaptcha.svelte (classic and enterprise) to see if organizing them by provider makes more sense. Thanks!

const journeyParam = $page.url.searchParams.get('journey');
const suspendedIdParam = $page.url.searchParams.get('suspendedId');
const captchaModeParam = $page.url.searchParams.get('captchaMode') as
| 'normal'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think maybe we should use the actual names of recaptcha here, normal is understandable but I think it may be more clear to use invisible and Not a Robot then we have a third type which is Score based

If we use normal and the world changes to Score based we are out of sync with the standard and we break the api.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. We could rename normal to checkbox or visible if that's clearer. No breaking behavior change, just a string value in the public interface.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed 'normal' to 'visible'. Chose 'visible' over 'checkbox' as it's provider-agnostic and describes the behavior clearly for both Google and hCaptcha. Let me know what you think.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just use the names of the recaptcha instead? "Not a robot" | "invisible" | "score" ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because the captcha mode option controls both google reCaptcha and hCaptcha. "Not a robot" is google's checkbox UI copy, not a mode name, and hCaptcha has no equivalent term. 'score' isn't a valid value here either, v3/score-based mode is detected directly from the AM payload (reCaptchaV3: true output) and never needs to be set by the consumer. So the only real choice a consumer makes is how the widget renders: does it show a visible checkbox or execute silently in the background? 'visible' | 'invisible' describes that behavior precisely and stays correct and consistent regardless of the provider.j

@cerebrl cerebrl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a few comments around the continued use of the captcha store and if it's still needed. The other captcha comments I'll leave to Ryan as I'm not familiar enough with the spec to weigh in.

Comment thread core/journey/callbacks/recaptcha-enterprise/recaptcha-enterprise.svelte Outdated
Comment thread core/journey/journey.store.ts Outdated
Comment on lines +629 to +632

export const recaptchaActionStore = derived(journeyStore, ($store) => ({
recaptchaAction: $store.recaptchaAction ?? '',
}));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this still needed now that we have the value woven into the callbackMetadata?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated. Passed recaptchaAction through callbackMetadata using the same initOptions pattern as captcha.mode. buildCallbackMetadata now merges recaptchaAction from initializationOptions into initOptions for both captcha callback types. Both components now read callbackMetadata?.initOptions?.recaptchaAction reactively. No store subscription and recaptchaActionStore` removed. Thanks!

@SteinGabriel SteinGabriel force-pushed the SDKS-2810 branch 2 times, most recently from 56be677 to e870cef Compare May 12, 2026 22:52
@SteinGabriel SteinGabriel marked this pull request as draft May 13, 2026 15:06
@SteinGabriel SteinGabriel marked this pull request as ready for review May 13, 2026 20:50

@ryanbas21 ryanbas21 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a few comments otherwise looks good

const hcaptcha = () => (window as Window & { hcaptcha?: HCaptcha }).hcaptcha;

export function detectEnterpriseProvider(elementClass: string): 'grecaptcha' | 'hcaptcha' {
return elementClass === 'g-recaptcha' ? 'grecaptcha' : 'hcaptcha';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't we change this class at the node level?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function was detecting the captcha provider based on the class name coming from the captcha callback, but ReCaptchaEnterpriseCallback is Google-only so the hCaptcha branch in the enterprise component was dead code.
Removed detectEnterpriseProvider, the HCaptcha interface, and the hcaptcha render branch. Enterprise always uses grecaptcha.


onMount(() => {
const captchaProvider: 'hcaptcha' | 'grecaptcha' =
recaptchaClass === 'g-recaptcha' ? 'grecaptcha' : 'hcaptcha';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

again i'm concerned that we're relying on this but it's a node level setting if I recall

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

captchaDivClass is read directly from the callback output (getOutputByName('captchaDivClass')), so the AM node does control it. We need it not just for the css class but to route between hCaptcha and Google. it drives script loading, and the hcaptcha.render() vs grecaptcha.render() branching. It's the only signal the callback exposes that we can use to determine the provider.

recaptchaClass === 'g-recaptcha' ? 'grecaptcha' : 'hcaptcha';

// v3 is always Google; v2 provider is derived from captchaDivClass
const scriptSrc = isV3

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

np: maybe an object that maps the urls to the type?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, good call! Replaced the ternary chain with a CAPTCHA_SCRIPT_URLS map key. Thanks!

Comment thread packages/login-widget/src/lib/interfaces.ts Outdated
@SteinGabriel SteinGabriel force-pushed the SDKS-2810 branch 2 times, most recently from c9f33f2 to 6199372 Compare May 14, 2026 21:25

@cerebrl cerebrl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the state of this PR. The only changes are the "effectful" utilities. Let's just reorganize/rename these files to reflect the fact that they are not pure, stateless functions.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is a new file, I'd like to move towards the pattern of utilities being pure, stateless functions. Since these manage "effects", like reading from the window object or interacting with the DOM, let's just reorganize/rename this as an effect.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated. Split all three files following the WebAuthn pattern. Pure functions remain in *.utilities.ts; DOM/window effects moved to *.effects.ts. Thanks!

Comment thread core/journey/callbacks/_utilities/recaptcha-enterprise.utilities.ts
Comment thread core/journey/stages/_utilities/recaptcha.utilities.ts Outdated

@vatsalparikh vatsalparikh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Provided code suggestions in respective lines. Below are some PR-wide suggestions

  • File structure of utilities and effects files is confusing. We should consolidate this logic under callbacks/ folder because this is callback related logic
    • We have effects and utilities under recaptcha-enterprise. We don't have this pattern, those should be moved out to dedicated _effects and _utilities folders.
    • Then we have effects and utilities under stages for recaptcha
    • And we have effects and utilities under journey/_utilities.
    • Any effects files should be under its own _effects folder and _utilities under its own _utilities folder.
  • For 1 pending check, please approve or deny the changes in chromatic before merging. I've found UI regressions from my PRs here before.
Image

Comment thread packages/login-widget/README.md Outdated

const mountMode =
((callbackMetadata?.initOptions?.mode as string | undefined) ?? 'visible') === 'invisible'
? 'invisible'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should define a type for captcha mode, maybe CaptchaMode. It seems like this value is referenced across lots of files. Defining it as a type will avoid typos and as casting, and provide a single reference across multiple files.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call. I added CaptchaMode = 'visible' | 'invisible' type in journey.interfaces.ts. Both callback components now import and use it. This also fixed a TS error where initOptions caused the reactive captchaMode to widen to string, which broke the loadEnterpriseScript mode param. Thanks!

// AM node may not expose the action input — non-fatal, token already set
}
} catch (err) {
console.error('[recaptcha-enterprise] execute() threw:', err);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we want to log out consoles in production code?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, that was a debug console.error. Removed, thanks.

mode: mountMode,
});
} catch (err) {
console.error('[recaptcha-enterprise] script load failed:', err);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

console in prod code again

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed. Thank you.

@SteinGabriel SteinGabriel force-pushed the SDKS-2810 branch 2 times, most recently from 7926ae1 to a6cc346 Compare May 15, 2026 22:14

@vatsalparikh vatsalparikh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current review non-blocking comments

  • We should document the recaptcha config gotchas because they deviate from official recaptcha enterprise docs. This should be documented in this repo's README file so the knowledge doesn't stay tribal.
  • Some new line-specific non-blocking comments

Previous review non-blocking comments

  • File structure of utilities and effects files is confusing. We should consolidate this logic under callbacks/ folder because this is callback related logic
    We have effects and utilities under recaptcha-enterprise. We don't have this pattern, those should be moved out to dedicated _effects and _utilities folders.
    Then we have effects and utilities under stages for recaptcha
    And we have effects and utilities under journey/_utilities.
    Any effects files should be under its own _effects folder and _utilities under its own _utilities folder.
  • For 1 pending check, please approve or deny the changes in chromatic before merging. I've found UI regressions from my PRs here before.

Comment thread apps/login-app/src/routes/e2e/widget/inline/+page.svelte Outdated
Comment thread core/journey/stages/_effects/captcha.effects.ts Outdated
@SteinGabriel SteinGabriel force-pushed the SDKS-2810 branch 4 times, most recently from 5836e96 to 3cfa823 Compare May 22, 2026 17:45
…erprise support

fix(captcha): replace javascript-sdk imports with journey-client in story/mock files and restore vitest coverage-v8 dep

refactor(captcha): replace captcha.store with initOptions in buildCallbackMetadata

refactor(captcha): thread recaptchaAction through callbackMetadata, remove recaptchaActionStore

refactor(captcha): unify script injection via shared captcha.utilities

refactor(captcha): separate effectful functions into *.effects.ts files

fix(captcha): guard executeEnterpriseCaptcha against missing grecaptcha and prevent double-render

fix(captcha): add Zod validation schema for captcha config option

docs(captcha): correct README — widget injects scripts automatically

fix(captcha): fix visible onError infinite loop, unsafe captchaMode cast, and console.error noise

fix(captcha): fix layer violation, TDZ, v3 error handling, and add initOptions tests

fix(captcha): remove redundant mountMode local; use reactive captchaMode in onMount

fix(captcha): fix existing-script race, empty apiUrl fallback, and v3 null deref

fix(captcha): fix Tier 2 findings — elementId in invisible render, enterprise onError, v3 error UI

refactor(captcha): remove dead re-export, drop redundant captchaMode type annotations

refactor(captcha): consolidate effects/utilities under callbacks/_effects and callbacks/_utilities
@SteinGabriel SteinGabriel merged commit 8b5670f into main May 27, 2026
21 checks passed
@SteinGabriel SteinGabriel deleted the SDKS-2810 branch May 27, 2026 20:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

4 participants